本文旨在指导大家如何创建和使用Windows下的DLL动态链接库以及通过DLL动态链接库导出导入这一过程介绍一些编译链接、动态加载的小知识。本章内容作为本学期项目开发的延伸扩展,目的是让大家对项目中使用的读写器接口函数库由来以及如何在项目中使用该函数库有更深入的理解。在这个过程,大家将会初步接触并了解:
+1. Windows静态链接库和动态链接库的一些背景知识;
+2. 如何创建和使用DLL动态链接库。
静态库和动态库的区别
@-静态链接库(.lib)
函数和数据被编译进一个二进制文件。在使用静态库编译链接成可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其他模块组合起来创建.exe可执行程序。
@-动态链接库(.lib & .dll)
一个引入库(.lib)文件和一个DLL(.dll)文件。引入库文件只包含该DLL导出的函数和变量的符号名,并没有包含实际代码,只是用来为链接程序提供必要信息,以便在可执行文件中建立动态链接时需要用到的重定位表,而.dll文件包含该DLL实际的函数和数据。在使用动态库编译链接成可执行文件时,只链接该DLL的引入库文件,该DLL文件中的函数代码和数据并不复制到可执行文件中,直到可执行程序执行时,才将该DLL映射到进程的地址空间中,然后访问DLL中导出的函数。
动态链接库包含能被可执行程序或其他DLL调用来完成某项工作的函数 → 动态链接库只有在其他模块调用库中的函数时才发挥作用。
@-为什么使用动态链接库
<1. 节省磁盘空间和内存
如果多个应用程序需要访问同样的功能,可以将该功能以DLL的形式提供,这样在机器上只需要一份该DLL文件就可以了,节省磁盘空间;多个应用程序使用同一个DLL,该DLL的页面只需要放入内存一次,所有的应用程序都可以共享它的页面,内存的使用更加有效。
<2. 采用多种语言编写动态链接库
采用自己熟悉的开发语言编写DLL,然后由其他语言编写的可执行程序来调用这些DLL。
<3. 增强产品功能
更新DLL,替换产品原有的DLL。
<4. 提供二次开发平台
用户利用DLL,调用其中实现的功能,开发业务所需的产品。
<5. 简化项目管理
并行开发,不同功能交由各项目小组以多个DLL的方式实现。
@-我们可以怎么做
把完成某种功能的函数放在一个动态链接库中,提供给其他程序调用。
利用VC++创建DLL
第一步: VC++ → File → New → Win32 Dynamic-Link Library → An Empty DLL Project,创建动态链接库工程;
第二步: VC++ → File → New → C++ Source(Header) File添加源文件或头文件,编写代码;
第三步: Build,便会在工程目录下的 Debug/ 下查看到 工程名.dll 动态链接库文件以及 工程名.lib 引入库文件。
具体代码如何编写以及生成动态链接库后,如何在我们的应用程序中调用库中的函数实现某种功能将会在下面章节仔细讲述。
从DLL中导出全局函数
为了让DLL导出一些函数,需要在每一个将要被导出的函数前面添加标识符: _declspec(dllexport)
1
2
3
4
5
6# 函数声明
_declspec(dllexport) ReturnType FunctionName(args...);
ReturnType FunctionName(args...){
...
}
从DLL中导出C++类
类似的,使用导出标识符: _declspec(dllexport)
1
2
3
4
5
6# 类指定导出标识
class _declspec(dllexport) ClassName
{
public:
ReturnType FunctionName(args...);
}
指定导出标识的类的所有函数都将被导出;否则,只有那些声明了导出标识的类成员函数才会被导出。1
2
3
4
5
6# 只有类成员函数指定导出标识
class ClassName
{
public:
_declspec(dllexport) ReturnType FunctionName(args...);
}
注意:
导出的成员函数必须具有public访问权限,否则即使被导出,也不能被其他程序访问。
使用dumpbin命令查看DLL导出导入信息
首先,DUMPBIN.EXE程序位于/Bin/目录下(中文版则是/VC98/Bin/)。为了在cmd命令行里面方便使用,我们将其所在的全局路径加入到环境变量Path中,如下图(火狐/IE浏览器可以点击查看大图)。
右键计算机,属性→高级系统配置→环境变量→双击系统变量中的Path→在变量值后面加上DUMPBIN.EXE程序所在全局路径。(注意:添加路径前需要有”;”隔开,因为变量值是通过”;”分隔的)
接下来我们就可以使用这个工具查看一个DLL提供的导出函数:
dumpbin -exports xxx.dll
(如下左图)。其中要注意的“ordinal列”列出的信息是导出函数的序号;“RVA列”是一些地址值,也就是导出函数在DLL模块中的位置,通过该地址值可以在DLL中找到相应的函数;“name列”是导出函数的名称,使用该DLL的客户端程序通过该名称找到所需的DLL导出函数。
还可以使用这个工具查看一个可执行模块依赖的动态链接库信息以及该动态链接库中被可执行模块调用的函数:
dumpbin -imports xxx.exe
。这里需要注意的是:一个可执行模块依赖的库函数名称必须与该动态链接库导出的函数名字相一致,因为可执行模块使用依赖的库函数名称来调用DLL中的函数,只有在二者相一致的情况下,可执行模块才能找到所需的DLL导出函数。因此客户端程序引用的函数符号名必须与DLL中的导出函数名称一致才能成功调用!
我们知道,C++支持函数重载,对于重载的多个函数来说,依照我们上述的说法,他们的导出函数名理应都是一样的,显然这样的话调用的时候会出问题。为了加以区分,C++会按照自己的规则篡改函数的名称,这一过程称为“名字改编”。一旦对导出函数名字进行改编,就会造成调用时可执行模块依赖的库函数名称与动态链接库导出的函数名字不一致的问题。这就需要在导出时设置导出规则,然后在调用时设置相匹配的调用约定,最终保证引用的函数名称与动态链接库中导出的函数名字一致。
DLL导出函数的名字改编问题
接上面所说的,C++编译器在生成DLL时,会对导出的函数进行名字改编,并且不同的编译器使用的改编规则不一样,因此改编后的名字是不一样的。这样,如果利用不同的编译器分别生成DLL和生成访问该DLL的客户端程序的话,后者在访问该DLL的导出函数时就会因二者函数符号不一致而出现调用失败的问题。
1. extern “C”
利用限定符:extern “C”(注意C要大写)可以解决C++和C语言之间相互调用时函数命名的问题,使用该限定符,动态链接库文件在编译时,导出函数的名称不会发生改编。下面是一个具体例子,1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17/******************************************************
* File Name: DllExample.cpp
* Creator: Tarantula-7
* Created Time: 2015.10.18
* Description: 使用extern "C"范例
******************************************************/
extern "C" _declspec(dllexport) int add(int a, int b);
extern "C" _declspec(dllexport) int subtract(int a, int b);
int add(int a, int b){
return a+b;
}
int subtract(int a, int b){
return a-b;
}
我们使用DUMPBIN.EXE命令来查看生成的DLL导出函数信息,如下图:
容易看到,导出函数名与函数声明中的函数名是一致的(= 后面的函数名为一个等价的导出函数名),这就是extern “C” 限定符的作用,我们可以把该限定符去掉,对比一下没有使用extern “C” 限定符该DLL的导出信息。
如下所示,把原先的声明注释掉,改为没有extern “C” 限定符修饰的普通导出函数声明。
1 | //extern "C" _declspec(dllexport) int add(int a, int b); |
正如上述所说的,没有添加extern “C” 限定符,C++编译器对导出函数进行了名字改编(从下图我们可以看到,改变后的名字加上了?作为前缀,@@…作为后缀)。
但是使用extern “C” 限定符这种方法有一个缺陷,就是不能用于导出C++类(如下图,函数名字仍然发生改编)。extern “C” 限定符只能用于导出上述的全局函数这种情况。
以下这种方式则是不允许的,编译时直接报错!
1 | class Point{ |
2. _stdcall标准调用
上述添加extern “C” 限定符这种方式保证不对导出函数名字进行改编,这样在引用导出函数时就可以直接通过原始函数名字进行调用,确保依赖库函数名称与导出的函数名字一致。
如果导出函数的调用约定发生了改变,那么即使使用了extern “C” 限定符,该函数的名字仍会按照调用约定发生相应的改编。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/******************************************************
* File Name: DllExample.cpp
* Creator: Tarantula-7
* Created Time: 2015.10.18
* Description: 使用_stdcall标准调用范例
******************************************************/
extern "C" _declspec(dllexport) int _stdcall add(int a, int b);
extern "C" _declspec(dllexport) int _stdcall subtract(int a, int b);
int _stdcall add(int a, int b){
return a+b;
}
int _stdcall subtract(int a, int b){
return a-b;
}
从下图的导出函数信息,我们可以知道:在添加了_stdcall关键字之后,导出函数的名字仍发生改编(在原始函数名字前面添加 _ ,后面添加一个@,接着是数字,表示函数参数所占的字节数,例如add函数具有两个int类型的参数,占用8个字节,所以add函数该数字为8)。
总结一下:
1)上述 第1.节 中没有添加_stdcall关键字,实际上该函数的调用约定是缺省的C调用预定,该约定不对名字进行改编;
2)添加_stdcall关键字后,调用约定变成标准调用约定,标准调用约定是WINAPI调用约定,也就是pascal调用约定,显然这种调用约定与C调用预定不一样,会对函数名字进行改编,改编方式则按照我们上面描述的规则进行。
3)因此,我们也可以在导出函数时添加调用约定,然后在引用函数时使用相匹配的调用约定进行调用,这样也能解决因名字改编出现的依赖库函数名称与导出的函数名字不一致的问题。
3. DEF模块定义文件
除了上述两种方式外,我们还可以通过一个称为模块定义文件(DEF)的方式,实现动态链接库文件在编译时,导出函数的名称不要发生改编。动态链接库源文件就和我们最开始介绍的普通导出函数源文件一致:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/******************************************************
* File Name: DllExample.cpp
* Creator: Tarantula-7
* Created Time: 2015.10.18
* Description: 使用DEF模块定义文件范例
******************************************************/
_declspec(dllexport) int add(int a, int b);
_declspec(dllexport) int subtract(int a, int b);
int add(int a, int b){
return a+b;
}
int subtract(int a, int b){
return a-b;
}
按照添加源文件的方式,我们为工程添加一个模块定义文件,File→New→Files选项卡→Text File→文件名后缀一定要是.def(文件名建议和工程名一致)。
模块定义文件(DllExample.def)内容如下:
1 | /****************************************************** |
LIBRARY xxx 一行可加可不加,用来指定动态链接库的内部名称,注意该名称与生成的动态链接库的名称(也就是我们的工程名)一定要匹配;
DESCRIPTION xxx 一行是对该动态链接库的描述,也是可加可不加;
EXPORTS 表明DLL将要导出的函数以及这些导出函数指定的符号名。当链接器在链接时,会分析EXPORTS下面的符号名,假如是上述例子所示的形式,即单独的符号名(add或subtract),并且符号名与源文件中定义的函数名一致时,就会以该符号名作为相应导出函数的函数名字,这样导出函数的名字便不会发生改编。(或者以另外一种形式:entryname=internalname(例如:myadd=add),其中entryname表示导出函数的函数名字,internalname则为源文件中定义的函数名,这种形式会对导出函数名字进行改编,entryname即为导出函数的函数名字。)
归根结底,名字改编造成依赖库函数名称与相应导出的函数名字不一致的问题,通过上述三种方式,最终保证可执行模块依赖的库函数名称与该动态链接库导出的函数名字相一致,只有这样指定函数才能成功调用。
加载DLL的两种方式
当我们把一些常用的功能封装成动态链接库之后,接下来就是在我们的应用程序中使用这些功能。首先,经过上述步骤创建好动态链接库,会在 Debug/ 目录下生成工程名.dll、工程名.lib:
+ 工程名.dll 就是我们所说的DLL动态链接库,我们的应用程序会在运行过程中动态加载该库;
+ 工程名.lib 则是文章开头所说的引入库文件,保存生成的DLL动态链接库中导出的函数和变量的符号名,并没有包含实际代码,只是用来为链接程序提供必要信息,以便在可执行文件中建立动态链接时需要用到的重定位表。
在程序中,有以下两种方式加载动态链接库:
@ 隐式链接;
@ 显示加载。
@-隐式链接方式加载DLL
第一步: 头文件中添加引入的外部函数声明。
在应用程序中调用动态链接库的导出函数之前,为了让编译器知道这些函数,需要对其作一个声明,通常前面加上 extern关键字 表明函数是在外部定义的。1
extern ReturnType FunctionName(args...);
除了使用extern关键字表明函数时外部定义的之外,还可以使用 _declspec(dllimport) 标识符来表明函数是从动态链接库中引入的。1
_declspec(dllimport) ReturnType FunctionName(args...);
注意: 使用 _declspec(dllimport) 标识符声明外部函数,它将告诉编译器该函数是从动态链接库中引入的,因此编译器可以生成运行效率更高的代码。故,如果调用的函数来自于动态链接库,应该采用这种方式声明外部函数。
另外,正如在 DLL导出函数的名字改编问题 一节中所说的,为了保证可执行模块依赖的库函数名称与该动态链接库导出的函数名字相一致,需要在导出时设置导出规则,然后在调用时设置相匹配的调用约定。当导出函数通过 extern “C”限定符 防止导出的函数名称发生改编,显然单单使用上述的声明会造成可执行模块依赖的库函数名称与该动态链接库导出的函数名字不一致,因为普通的外部声明引用的导出函数,其名字是对应于被C++编译器进行改编了的,而添加extern “C”限定符的导出函数名称并没有发生改编,所以,对应的需要使用如下的外部定义:1
extern "C" _declspec(dllimport) ReturnType FunctionName(args...);
经过上述的分析,我们也知道,没有添加 _stdcall关键字 修饰使用的是 C调用约定 ;那么当导出函数通过添加 _stdcall关键字 设置为 标准调用约定 ,则相应的外部定义声明应如下:1
extern "C" _declspec(dllimport) ReturnType _stdcall FunctionName(args...);
第二步: 包含该动态链接库提供的引入库文件。
添加外部引用函数声明后,我们还需要将DLL对应的引入库文件导入到工程中;不然,即使添加了外部应用声明,源文件能够成功编译,但是当链接器进行链接生成可执行程序时,因为缺少引入库文件,链接器会找不到相应的外部引用函数实现,造成链接失败!导入引入库文件的作用正是告诉链接器这些外部引用函数是以动态链接库的形式实现的,在运行时动态加载,这样就能够解决链接器链接失败的问题。
具体操作如下: 菜单栏Project→ Settings→ Link选项卡→ 选择Input(输入设置)作为”Category”→ “Object/library modules”输入动态链接库对应的引入库文件;当引入库文件直接放在工程目录下,“Additional library path”为空,当放在其他路径下,则需要添加该路径说明,链接器会按照该路径寻找引入库文件;在我们的项目中,需要调用设备的接口函数,各个接口函数是以动态链接库的形式提供,“ZM124U.lib”为对应的引入库文件,见下图右侧,“Additional library path”为”./libs”,这是因为该引入库文件放置在工程目录下的 libs 文件夹下,其中“ . “表示当前路径,也就是工程目录。
到这里,我们就完成了通过隐式链接的方式加载动态链接库的所有操作,可以生成一个需要依赖动态链接库的可执行程序;最后,因为该可执行程序运行时需要动态加载动态链接库才能执行某种功能,所以我们需要将相应的DLL动态链接库文件放在程序能够搜索到的路径下(关于动态链接库搜索顺序可以点击查看 几点注意的地方 一节中的第1)点)。
建议: 将动态链接库与可执行程序放在同一目录下。
@-隐式链接方式下动态链接库创建和使用完善方案
在实际应用中,多采用隐式链接方式加载动态链接库。通过上述的介绍,相信大家对创建动态链接库以及在应用程序中采用隐式链接方式引用创建好的动态链接库的流程有了比较清晰的认识,我们不妨再回顾一下:
+. 首先,创建动态链接库工程,其中需要注意的是需要设置好导出函数的导出规则,通过 extern “C” _declspec(dllexport) 进行函数声明;如果采用标准调用约定,则需要添加 _stdcall 关键字。
+. 在应用程序中调用生成的动态链接库,先是需要添加调用函数的外部定义声明,再将该动态链接库对应的引入库文件导入工程;注意调用约定与导出规则必须相匹配才能成功调用。
在实际应用中,完善的创建与使用动态链接库的方案是通过一个统一的 头文件声明 解决调用约定与导出规则相匹配这个问题,我们采用这种标准来完善上述举出的例子。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20/******************************************************
* File Name: DllDemo.h
* Creator: Tarantula-7
* Created Time: 2015.10.19
* Description: 动态链接库创建和使用完善方案范例
******************************************************/
/************************* C调用约定声明如下 *********************/
DLLDEMO_API int add(int a, int b);
DLLDEMO_API int subtract(int a, int b);
/************************* C调用约定声明 *************************/
/************************* 标准调用约定声明如下 ******************/
//DLLDEMO_API int _stdcall add(int a, int b);
//DLLDEMO_API int _stdcall subtract(int a, int b);
/************************* 标准调用约定声明 **********************/
在 DllDemo.h 头文件声明中通过一个预编译宏DLLDEMO_API
,当该宏未被定义时,将其定义为 extern “C” _declspec(dllimport) ,是外部定义声明修饰符;所以,我们可以在应用程序中直接通过 #include “DllDemo.h” 声明我们要调用的从动态链接库中导出的函数。
以上是解决在应用程序中调用的问题,那么这种方式在创建动态链接库时又是怎样进行导出函数声明的呢?1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29/******************************************************
* File Name: DllDemo.cpp
* Creator: Tarantula-7
* Created Time: 2015.10.19
* Description: 动态链接库创建和使用完善方案范例
******************************************************/
#define DLLDEMO_API extern "C" _declspec(dllexport)
#include "DLLDemo.h"
/************************* C调用约定实现如下 ******************/
int add(int a, int b){
return a+b;
}
int subtract(int a, int b){
return a-b;
}
/************************* C调用约定实现如下 ******************/
/************************* 标准调用约定实现如下 ***************/
//int _stdcall add(int a, int b){
// return a+b;
//}
//int _stdcall subtract(int a, int b){
// return a-b;
//}
/************************* 标准调用约定实现如下 ***************/
相似的,还是需要通过 #include “DllDemo.h” 对函数进行声明,但是,在 #include “DllDemo.h” 之前,对DLLDEMO_API
宏进行定义,为 extern “C” _declspec(dllexport) ,是导出函数标识符;这样,当包含头文件时,因为DLLDEMO_API
宏已经被定义, #define DLLDEMO_API extern “C” _declspec(dllimport) 一行不会被执行,从而完成了对相应函数的导出声明。
通过以上这种方式,使得我们在使用动态链接库提供的接口时,只需要包含一个头文件即可完成对调用函数的外部定义,而该头文件则是开发该动态链接库的程序员提供;另外,通过这种方式,保证了应用程序调用接口函数采用的调用约定与对应动态链接库的导出规则相匹配。 (我们的设备接口函数就是按照这种方式实现的)
@-显示加载方式加载DLL
显示加载方式是另外一种加载动态链接库的方式,这里简单介绍一下。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29void CDLLtestDlg::OnBtnSubtract()
{
HINSTANCE hInst = LoadLibrary("./DLLDemo.dll"); // 动态加载DLL
/****************************** 标准调用预定 ****************************/
//typedef int (_stdcall *SUBTRACTPROC)(int a, int b); // 定义函数指针类型
//SUBTRACTPROC Subtract = (SUBTRACTPROC)GetProcAddress(hInst, "_subtract@8");
// 通过导出符号名获取DLL的导出函数
// GetProcAddress的第二个参数是按照标准调用规则改编后的导出符号名
//SUBTRACTPROC Subtract = (SUBTRACTPROC)GetProcAddress(hInst, MAKEINTRESOURCE(2));
// 也可以通过序号获取DLL的导出函数
/****************************** 标准调用预定 ****************************/
/****************************** C调用预定 ****************************/
typedef int (*SUBTRACTPROC)(int a, int b);
SUBTRACTPROC Subtract = (SUBTRACTPROC)GetProcAddress(hInst, "subtract");
/****************************** C调用预定 ****************************/
if(!Subtract){ // 函数指针为NULL说明获取DLL导出函数失败
MessageBox("获取函数地址失败!");
return;
}
CString str;
str.Format("5-3=%d", Subtract(5, 3)); // 通过Subtract调用DLL中的subtract函数
MessageBox(str);
FreeLibrary(hInst); // 释放该DLL的引用
}
注意: 1. 不同的调用约定在定义函数指针时是有区别的,如果采用标准调用约定,则需要添加 _stdcall 关键字。
2. 通过导出函数名获取DLL导出函数时(GetProcAddress的第二个参数),要注意是否发生名字改编。使用 extern “C” 同时采用C调用约定,导出函数的名字不会发生改编;采用标准调用约定,会对函数名字进行改编,改编原则是原始函数名前面添加“ _ “,后面添加“ @数字” ,其中数字为函数参数所占字节数。
3. 通过序号获取DLL导出函数时( MAKEINTRESOURCE(序号) ),最好通过dumpbin命令从导出信息中获取函数对应的序号(“ ordinal列 “信息);不过由于这种方式代码可读性不强,不建议使用。
4. 采用显示加载方式加载DLL,在需要访问时调用LoadLibrary函数加载该DLL;当不需要访问该DLL时,调用FreeLibrary函数减少对该DLL的引用计数,当此计数变为0时,该DLL模块将从调用进程的地址空间卸载;调用FreeLibrary函数后, hInst 句柄不再有效。
5. 采用隐式链接方式访问DLL时,在程序启动时也是通过LoadLibrary函数加载该进程需要的动态链接库的;如果程序需要访问十多个DLL,如果都采用隐式链接方式加载它们的话,那么在改程序启动时,这些DLL都需要被加载到内存中,并映射到调用进程的的地址空间,这样将加大程序的启动时间。
6. 一般来说,程序运行过程中只是在某个条件满足时才需要访问某个DLL中的某个函数,这种情况下,就可以采用显式加载的方式访问DLL,在需要时才加载所需的DLL。
动态链接库搜索顺序
当应用程序运行时,系统将会为它分配一个4GB(x86-32架构)的地址空间,然后加载模块会分析该应用程序的输入信息,从中找到改程序将要访问的动态链接信息,然后在用户机器上搜索这些动态链接库,进而加载它们,搜索的顺序如下:
<1. 程序的执行目录,即.exe文件所在目录;
<2. 当前目录“.”,也叫当前工作目录,即进行某项操作的目的目录;
举例: A居住在珠海,这是执行目录;但是A的工作地点是在广州,这是当前目录。
<3. 系统目录,常见的就是C:\Windows\System32、C:\Windows\System,64位系统的C:\Windows\SysWOW64;
<4. Path环境变量中所列出的路径。
几点注意的地方
1)应用程序不需要调用一个动态链接库的所有导出函数,可以根据需要调用;
2)应用程序需要调用某个动态链接库提供的函数时,在程序链接时只需要包含该动态链接库提供的引入库文件,引入库文件并没有包含实际代码,只是用来为链接程序提供必要信息,以便在可执行文件中建立动态链接时需要用到的重定位表;
3)导出时设置的调用约定必须与引用时匹配,保证引用符号名与动态链接库中的导出符号名一致。
Reference:
- 《Win32动态链接库与静态链接库的区别》
https://yq.aliyun.com/articles/2740?spm=5176.blog2739.yqblogcon1.8.pJ3E7X- error C1041: 无法打开程序数据库“x:\projects\hellococo\debug.win32\vc120.pdb”;如果要将多个 CL.EXE 写入同一个 .PDB 文件,请使用 /FS 。
http://home.eeworld.com.cn/my/space-uid-291513-blogid-239457.html
Version Control
版本号 | 日期 | 内容 | 作者 |
V0.1 | 2015.10.16 | 起草博客 | Tarantula-7 |